Skip to content

feat(*): Migrate to useSyncExternalStore#7411

Merged
Ephem merged 75 commits intomainfrom
fredrik/user-4043-remove-clerk-state-contexts-from-provider-and-rely-on
Jan 28, 2026
Merged

feat(*): Migrate to useSyncExternalStore#7411
Ephem merged 75 commits intomainfrom
fredrik/user-4043-remove-clerk-state-contexts-from-provider-and-rely-on

Conversation

@Ephem
Copy link
Member

@Ephem Ephem commented Dec 9, 2025

Description

This is the full implementation of PoCs #7194 and #7267.

The main goal of the PR is to refactor the SDKs away from using a top level addListener+state+context combo for auth-state, to using useSyncExternalStore.

  • Merge the ContextProviders from ui and react into shared/react
  • Removes the clerk.addListener() in that provider, also removes most contexts
    • Breaking change: Several context providers like UserContext are no longer exported from shared/react
  • Adds new hooks that subscribes directly to clerk via useSyncExternalStore
    • These "base" hooks, like useUserBase are also reexported as useUserContext to avoid breaking change for these
    • They have the exact same signature, leaving all other hooks untouched
    • We should discuss this, both naming and if we want to do more breaking changes here instead

More implementation notes

  • Fixes bug in useClearQueriesOnSignOut as a conditional hook was causing the new tests to fail
  • Removing initialAuthState from useAuth also fixes a bug where the initial auth state was being used during the transitive state
    • This bug would return null instead of undefined on the page right after sign-in, which put the app in an akward state where it momentarily believed you were still signed out - Could lead to redirects back to /sign-in
  • Renamed this.#setAccessors(); to this.#updateAccessors(); which now calls this.#emit() by default
    • Can pass skipInitialEmit to skip emitting
    • This was done because it's important to not let the clerk state and the app state tear
  • this.#emit()
    • Now saves this.__internal_lastEmittedResources, so useSES can read the last resources emitted in getSnapshot
    • Supports a new skipInitialEmit to avoid emitting and re-rendering on useSES -> subscribe
  • Minor refactors for readability in adjacent code in clerk-js
  • Some type improvements

With all this done, I noticed two timing issues in the bridge between the host React app and the Components app, which is a separate app. Both happened because components that should have unmounted had time to re-render and fire effects, because useSES emitted slightly earlier than setState.

  • In ui/src/Components.tsx, the nodes (current components and which node they should be portaled into) was being set via setState
    • The SignInRoutes has a fallback, which is to redirect back to /sign-in
    • After sign in from password in TanStack Router specifically, SignIn stayed mounted and re-rendered because of useSES, even though the node was gone and the next page had already been rendered
    • This hit the fallback <RedirectToSignIn /> route
    • This was fixed by:
      • Making the nodes a ref instead
      • Adding a useSyncExternalStore where we don't use the return value, to make sure <Components /> re-renders before the child and unmounts it
  • In BaseRouter.tsx, the async baseNavigate was doing setRouteParts as part of the navigation
    • This ensured the state was set before returning to the caller
    • This did not ensure the component had re-rendered as a response to the route change
    • On sign in, useSES in a child ran first and similar to the above case, triggered a redirect back to /sign-in
    • This was fixed by a flushSync to ensure the components had properly re-rendered, guaranteeing setActive does not leave the transitive state before the host app AND the Clerk components are ready

Both of these fixes might be considered controversial, happy for feedback!

Note that the unmount flow has multiple .then() which schedules unmounting into several microtasks even when UI is available. We might want to make this synchronous for even tighter control, but this was not necessary to fix these bugs.

Note also that these bugs result from having multiple React apps. The normal React lifecycle should guarantee they don't happen in host apps.

Background and motivation

Feel free to skip this if you already have familiarity, but as this is a fairly large change, I wanted to write down the full reasoning for this change for posterity.

As I see it, Clerk has different kinds of state. JWT state, piggybacking state and fetched state. Worth noting is that the fetched state has previously used SWR under the hood, but is about to change over to a custom React Query implementation, but both of these already uses useSyncExternalStore and are untouched by this PR.

The JWT state is fairly self-explanatory, and the client piggybacking state is asynchronous state that is included as part of other API-calls as a self-refreshing mechanism. The clerk-js base abstraction updates this for example when it polls for the /tokens, and pushes the changes to the SDKs.

The JWT state and the piggybacking state together represents the foundational "auth state". This represents the user, the active organization etc.

Current solution

clerk-js has an addListener method. The React SDKs subscribes to this in an effect in their <ClerkProvider> and sets an internal state on every update. This state is then massaged a bit and placed on a few different contexts, and the different hooks, like useAuth, useUser etc read from these contexts.

<ClerkProvider> also supports passing in an initialState. If this is passed in, you can access it during SSR, but because there is no good way to prefetch the piggybacking or fetched state currently, only the useAuth state is really feasible to server render.

In Next, doing <ClerkProvider dynamic> automatically provides the initialState for useAuth behind the scenes. The reason we don't always do this is that it opts out of static generation and caching.

Current challenges and issues

Subscribing at the top has a few downsides however. It's currently hard to make things more streamy by allowing initialState to be represented as a promise, as we currently need to await it at the top. This is something we want to do to support cacheComponents and Partial prerendering better, allowing for finer-grained caching. This also opens up for making things suspenseful.

It also doesn't play well with concurrent rendering. A concrete example of this is an active bug that can happen when one subtree suspends long enough during page load that clerk-js has time to load. When it unsuspends, it reads the loaded auth state and not the initial one, causing hydration mismatches.

Another downside is how context is hard to use for fine grained updates, making it hard to implement things like selectors on top of it.

useSyncExternalStore is the canonical way to safely subscribe to an external store, and has none of these downsides.

Testing PR: https://github.com/clerk/dashboard/pull/7994

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

Summary by CodeRabbit

  • Breaking Changes

    • useAuth no longer accepts an initialAuthState parameter — provide initialState via ClerkProvider.
    • Removed public context exports: ClientContext, SessionContext, UserContext, OrganizationProvider.
  • New Features

    • Listener API accepts skipInitialEmit option to opt out of initial events.
    • InitialStateProvider/initial-state hooks and a consolidated ClerkContextProvider added.
    • Transitions demo page added.
  • Bug Fixes

    • Fixed hydration mismatches and brief user:null flash after sign-in.

✏️ Tip: You can customize this high-level summary in your review settings.

Ephem added 30 commits November 10, 2025 14:35
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/ui/src/contexts/CoreClientContext.tsx`:
- Around line 5-21: The hooks useCoreSignIn and useCoreSignUp currently throw
when useClientContext() is falsy and that can happen during normal Clerk
loading; change them to mirror useSessionList's behavior by returning a safe
"not loaded" shape instead of throwing: call useClientContext(), and if client
is falsy return an object that signals isLoaded: false and includes the same
public API shape/fields expected from SignInResource/SignUpResource (e.g., no-op
methods or placeholders) so consumers can render during loading; update both
useCoreSignIn and useCoreSignUp to perform this guard and return the safe
fallback rather than throwing.

Copy link
Member

@nikosdouvlis nikosdouvlis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wish every PR had a description like this 👍🏻 Makes it much easier to understand the "why" behind the changes.

// Avoid rendering nested ClerkProviders by checking for the existence of the ClerkNextOptions context provider
const isNested = Boolean(useClerkNextOptions());
if (isNested) {
if (rest.initialState) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just double checking - is there any difference here between initialState being null vs undefined? The condition if (rest.initialState) would be false for both, but wondering if we need to handle them differently.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Always good to double check! I don't think initialState: null is valid according to the types, and I do think the correct way to handle that case is to ignore it.

One might argue we'd want a way to reset the initialState for a subtree if you do something like:

<ClerkProvider dynamic>
  <ClerkProvider>

but I don't really see a case for that.

const clerk = useClerkInstanceContext();
const initialState = useInitialStateContext();
const getInitialState = useCallback(() => {
return initialState?.session as SignedInSessionResource | undefined;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cast initialState?.session as SignedInSessionResource is a bit unsafe since InitialState.session is typed as SessionResource | undefined. Probably fine in practice since session is always signed-in when present, but a type guard would be cleaner if we want to be strict about it.

Copy link
Member Author

@Ephem Ephem Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure I just moved this cast from elsewhere, there were a bunch of those. I'll double check and will dig a bit deeper to see if I can clean it up a bit.

I've seen quite a few of these that doesn't feel great. 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was from deriveFromSsrInitialState, which has a bunch of these and we should probably clean it up.

I refactored useSessionBase to call deriveFromSsrInitialState instead of using .session and casting directly. There's no real overhead to that and that way we at least keep the casts in one place.

useCallback(() => {
return clerk.__internal_lastEmittedResources;
}, [clerk]),
);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useSyncExternalStore here doesn't provide a getServerSnapshot, which means it will throw during SSR. I assume the Components app is never server-rendered, but should we add a comment explaining that this cannot be server rendered at the moment? Would help future us if someone tries to SSR this later.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this as the getServerSnapshot, together with a comment:

useCallback(() => {
  return clerk.__internal_lastEmittedResources;
}, [clerk]),

This will prevent it from throwing when we want to play around with SSR, and should actually be fine here since we don't use the returned state anyway.

* Updates the accessors for the Clerk singleton and emits.
* If dangerouslySkipEmit is true, the emit will be skipped and you are responsible for calling #emit() yourself. This is dangerous because if there is a gap between setting these and emitting, library consumers that both read state directly and set up listeners could end up in a inconsistent state.
*/
#updateAccessors = (session?: SignedInSessionResource | null, options?: { dangerouslySkipEmit?: boolean }) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dangerouslySkipEmit flag feels a bit scary. The JSDoc warning is good, but I wonder if it makes sense to add something in our CLAUDE.md about this pattern so future contributors (and Claude) know when it's safe to use?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea!

Note that the semantics for existing code is unchanged, so hopefully refactoring the emit into here and adding this "dangerous" option will make it clearer that gaps in timing between update and emit are dangerous. I agree we should do everything we can to get people to not use this for anything new. 😅

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This felt a bit specific/niche to litter CLAUDE.md etc with, but I expanded the comment a bit to advice against using the option for new code and listed an alternative approach.

this.user = this.session ? this.session.user : null;
/**
* Updates the accessors for the Clerk singleton and emits.
* If dangerouslySkipEmit is true, the emit will be skipped and you are responsible for calling #emit() yourself. This is dangerous because if there is a gap between setting these and emitting, library consumers that both read state directly and set up listeners could end up in a inconsistent state.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 We now have skipInitialEmit (on addListener) and dangerouslySkipEmit (on #updateAccessors). The difference is subtle: one controls whether a new subscriber gets an immediate callback, the other controls whether setting accessors triggers an emit. I wonder if there's a way to consolidate or simplify this flow in the future? Not blocking, but might be worth thinking about as a follow-up to reduce the number of "skip emit" concepts floating around.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed! I plan to implement @bratsos suggestion in a follow up, which should let me remove skipInitialEmit entirely. If you want that behavior, you'll be able to subscribe instead of addListener.

Copy link
Member

@jacekradko jacekradko left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:shipit:

@Ephem
Copy link
Member Author

Ephem commented Jan 28, 2026

!allow-major

@Ephem Ephem changed the title feat(clerk-js,shared,react,nextjs): Migrate to useSyncExternalStore feat(core): Migrate to useSyncExternalStore Jan 28, 2026
@Ephem Ephem changed the title feat(core): Migrate to useSyncExternalStore feat(*): Migrate to useSyncExternalStore Jan 28, 2026
@Ephem Ephem merged commit 583f7a9 into main Jan 28, 2026
42 of 45 checks passed
@Ephem Ephem deleted the fredrik/user-4043-remove-clerk-state-contexts-from-provider-and-rely-on branch January 28, 2026 12:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants